use std::collections::BTreeMap; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use anyhow::{Context, Result}; // Safe for text and double-quoted attribute contexts. use html_escape::encode_double_quoted_attribute as escape; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_encode}; use crate::catalog::Repository; use crate::highlight::{Cache as GrammarCache, Highlighter}; /// Blobs larger than this get a raw download instead of a rendered page. const MAX_RENDER_BYTES: u64 = 1 << 20; /// Cooperative cancellation for background builds; set when a /// human-triggered build wants the machine instead. pub(crate) type BuildCancel = Arc; pub(crate) fn bail_if_cancelled(cancel: &Option) -> Result<()> { if cancel .as_ref() .is_some_and(|flag| flag.load(Ordering::Relaxed)) { anyhow::bail!("site build cancelled"); } Ok(()) } const PATH: &AsciiSet = &NON_ALPHANUMERIC .remove(b'-') .remove(b'.') .remove(b'_') .remove(b'~') .remove(b'/'); /// Percent-encodes a slash-separated path for use in a URI. pub(crate) fn encode_path(path: &str) -> PercentEncode<'_> { percent_encode(path.as_bytes(), PATH) } /// `tip` names the page's ref and the commit it points at; the client reads /// them from `
` to resolve hash routes relative to /// the page. fn page( instance_name: &str, title: &str, description: &str, tip: Option<(&str, gix::ObjectId)>, body: &str, ) -> String { format!( "\n\ \n\ \n\ \n\ \n\ {title}\n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\n{noscript}{body}
\n\n\ \n", instance_name = escape(instance_name), title = escape(title), description = escape(description), format_version = crate::generate::OUTPUT_FORMAT_VERSION, tip = tip .map(|(name, oid)| format!(" data-ref=\"{}\" data-tip=\"{oid}\"", escape(name))) .unwrap_or_default(), // only repo pages lose anything to a missing client noscript = if tip.is_some() { NOSCRIPT } else { "" }, ) } const NOSCRIPT: &str = r#" "#; pub(crate) fn catalog_index(instance_name: &str, repositories: &[Repository]) -> String { let mut body = String::from("

repositories

\n"); if repositories.is_empty() { body.push_str("

no repositories found

\n"); } for repos in repositories.chunk_by(|a, b| a.user == b.user) { let user = &repos[0].user; let _ = writeln!( body, "

{}

", encode_path(user), escape(user), ); catalog_list(&mut body, repos); } let title = format!("repositories - {instance_name}"); let description = format!("Git repositories hosted on {instance_name}."); page(instance_name, &title, &description, None, &body) } /// `repositories` must all belong to `user`. pub(crate) fn user_index(instance_name: &str, user: &str, repositories: &[Repository]) -> String { let mut body = format!("

{}

\n", escape(user)); catalog_list(&mut body, repositories); let title = format!("{user} - {instance_name}"); let description = format!("Git repositories by {user} on {instance_name}."); page(instance_name, &title, &description, None, &body) } fn catalog_list(body: &mut String, repositories: &[Repository]) { body.push_str("\n"); } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RefKind { Branch, Tag, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum StaticMode { Highlighted, /// Blob pages carry escaped plain text; the client highlights them. Plain, } #[derive(Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub(crate) struct BlobVersion { pub oid: String, pub mode: u16, pub touched: String, } #[derive(Clone, Copy)] pub(crate) struct BlobReuse<'a> { pub versions: &'a BTreeMap, pub previous: Option<(&'a Path, &'a BTreeMap)>, } pub struct Site<'a> { pub repo: &'a gix::Repository, pub instance_name: String, pub name: String, pub base_url: String, pub description: Option, pub clone_url: Option, } impl Site<'_> { /// Display names like `user/repo` shorten to the repo part in crumbs. fn crumb_name(&self) -> &str { self.name.rsplit('/').next().unwrap_or(&self.name) } fn ref_href(&self, tip: &RefTip) -> String { match tip.static_mode { None => format!("{}#{}", self.base_url, tip.commit_id), Some(_) => { format!("{}ref/{}/", self.base_url, encode_path(&tip.name)) } } } /// Name, description and clone command; the same on every page of the repo. fn repo_header(&self) -> String { let mut html = format!("
\n

{}

\n", escape(&self.name)); if let Some(desc) = &self.description { let _ = writeln!(html, "

{}

", escape(desc)); } html.push_str("
\n"); if let Some(url) = &self.clone_url { let _ = writeln!(html, "
git clone {}
", escape(url)); } html.push_str("
\n"); html } } /// Author, summary, short sha and date of one commit, as a status line. fn commit_panel(repo: &gix::Repository, oid: gix::ObjectId) -> String { let Ok(commit) = repo.find_object(oid).map(|o| o.try_into_commit()) else { return String::new(); }; let Ok(commit) = commit else { return String::new(); }; let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default(); let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default(); format!( "

\ {author}\ {summary}\ {short}\ {time}

\n", author = escape(&author), summary = escape(&summary), short = commit.id().shorten_or_id(), time = time_of(&commit), ) } pub struct RefTip { pub kind: RefKind, pub name: String, pub commit_id: gix::ObjectId, pub static_mode: Option, } /// All local branches and tags, peeled to commits. Refs that don't peel to a /// commit (e.g. tags of blobs) are skipped. pub fn list_refs(repo: &gix::Repository) -> Result> { let platform = repo.references()?; let mut tips: Vec = Vec::new(); for (kind, iter) in [ (RefKind::Branch, platform.local_branches()?), (RefKind::Tag, platform.tags()?), ] { for r in iter { let mut r = r.map_err(|e| anyhow::anyhow!("failed to iterate refs: {e}"))?; let name = r.name().shorten().to_string(); // A branch and tag may share a short name. The flat /ref/ // namespace gives the branch precedence. if kind == RefKind::Tag && tips.iter().any(|t| t.kind == RefKind::Branch && t.name == name) { continue; } if let Ok(commit) = r.peel_to_commit() { tips.push(RefTip { kind, name, commit_id: commit.id, static_mode: None, }); } } } Ok(tips) } /// The branch HEAD points at, if it exists among `tips` (falling back to the /// first branch, matching forge behaviour for detached/unborn HEADs). pub fn head_tip<'a>(repo: &gix::Repository, tips: &'a [RefTip]) -> Option<&'a RefTip> { let head = repo.head_name().ok().flatten().map(|n| n.shorten().to_string()); tips.iter() .find(|t| t.kind == RefKind::Branch && Some(&t.name) == head.as_ref()) .or_else(|| tips.iter().find(|t| t.kind == RefKind::Branch)) } pub fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, contents).with_context(|| format!("writing {}", path.display())) } /// Renders the full static view of one ref (tree pages, blob pages, raw files /// for binaries) into `out`, which is typically a staging dir later swapped /// into place at `/ref//`. pub fn render_ref( site: &Site, tips: &[RefTip], tip: &RefTip, commit: &gix::Commit, out: &Path, reuse: Option>, highlights: Arc, cancel: Option, ) -> Result<()> { let mut r = RefRenderer::new(site, tips, tip, commit, out, highlights.clone(), cancel.clone()); let tree = commit.tree()?; let mut blobs = Vec::new(); r.walk(&tree, &mut Vec::new(), &mut blobs)?; // Blob pages dominate build time and are independent of each other, so // fan out; the repository is only ever read, so each worker gets its own // thread-local handle and reusable highlighter. Page costs vary wildly // (and cluster by directory), so workers pull from a shared counter // rather than taking fixed shares. let ctx = BlobContext { instance_name: site.instance_name.clone(), name: site.name.clone(), base_url: site.base_url.clone(), urlbase: r.urlbase.clone(), repo_header: site.repo_header(), label: tip.name.clone(), tip_id: commit.id, highlight: tip.static_mode != Some(StaticMode::Plain), out: out.to_owned(), reuse, highlights, cancel, }; let repo = site.repo.clone().into_sync(); let next = AtomicUsize::new(0); rayon::broadcast(|_| { let repo = repo.to_thread_local(); let mut highlighter = Highlighter::new(ctx.highlights.clone()); while let Some(job) = blobs.get(next.fetch_add(1, Ordering::Relaxed)) { if let Err(error) = bail_if_cancelled(&ctx.cancel) .and_then(|()| blob_page(&ctx, &repo, &mut highlighter, job)) { next.store(blobs.len(), Ordering::Relaxed); return Err(error); } } Ok(()) }) .into_iter() .collect() } /// Everything a blob page needs besides the per-worker repository handle and /// highlighter. struct BlobContext<'a> { instance_name: String, name: String, base_url: String, urlbase: String, repo_header: String, label: String, tip_id: gix::ObjectId, highlight: bool, out: PathBuf, reuse: Option>, highlights: Arc, cancel: Option, } struct BlobJob { path: Vec, oid: gix::ObjectId, is_link: bool, /// Switcher and crumbs, prebuilt during the walk: they need ref-wide /// state that the workers don't carry. chrome: String, } fn blob_page( ctx: &BlobContext, repo: &gix::Repository, hl: &mut Highlighter, job: &BlobJob, ) -> Result<()> { let joined = job.path.join("/"); let rel = format!("blob/{joined}"); // Embedded images may change without the markdown blob changing. if !is_markdown(&joined) && let Some(reuse) = ctx.reuse && let Some((previous, previous_versions)) = reuse.previous && reuse_blob( previous, previous_versions, reuse.versions, &joined, &ctx.out, ) { return Ok(()); } let data = &repo.find_object(job.oid)?.data.to_vec(); let size = data.len() as u64; let image = !job.is_link && image_mime(&joined).is_some(); let raw_url = format!("{}raw/{}/{}", ctx.base_url, job.oid, encode_path(&joined)); let looks_binary = data[..data.len().min(8192)].contains(&0); let text = (!job.is_link && !image && !looks_binary && size <= MAX_RENDER_BYTES) .then(|| String::from_utf8_lossy(data)); let lines = text.as_ref().map_or(0, |text| text.lines().count()); let stats = if job.is_link { format!("symlink \u{2192} {}", escape(&String::from_utf8_lossy(data))) } else if text.is_some() { format!( "{}{lines} line{}", human_size(size), if lines == 1 { "" } else { "s" }, ) } else { format!( "{}{}", if image { "image" } else if looks_binary { "binary file" } else { "large file" }, human_size(size), ) }; let touched = ctx .reuse .and_then(|reuse| reuse.versions.get(&joined)) .and_then(|version| gix::ObjectId::from_hex(version.touched.as_bytes()).ok()) .unwrap_or(ctx.tip_id); let mut body = format!( "{repo_header}{panel}
{chrome}{stats}\ raw
\n", repo_header = ctx.repo_header, panel = commit_panel(repo, touched), chrome = job.chrome, ); if let Some(text) = text { let name = job.path.last().expect("blob path is never empty"); // Plain mode defers highlighting to the client, which detects the // language from the path carried in data-hl. let (src_attrs, code) = if ctx.highlight { let lang = crate::grammar_manifest::detect(name); let code = lang .and_then(|lang| hl.highlight_lines(lang, &text).ok()) .unwrap_or_else(|| text.lines().map(|line| escape(line).to_string()).collect()); let class = lang.map(|l| format!(" language-{l}")).unwrap_or_default(); (format!("class=\"code src{class}\""), code) } else { ( format!("class=\"code src\" data-hl=\"{}\"", escape(&joined)), text.lines().map(|line| escape(line).to_string()).collect(), ) }; let mut source = format!("
");
        let line_count = code.len();
        for (i, line) in code.into_iter().enumerate() {
            let number = i + 1;
            let _ = write!(
                source,
                "{number}{line}"
            );
            if number < line_count {
                source.push_str("\n");
            }
            source.push_str("");
        }
        source.push_str("
"); if is_markdown(name) { let urls = RelativeUrls { repo, base_url: &ctx.base_url, urlbase: &ctx.urlbase, commit: ctx.tip_id, directory: &job.path[..job.path.len() - 1], }; let rendered = markdown(hl, &text, |url, image| urls.resolve(url, image)); let _ = writeln!( body, "
\ \ \ \ \
{rendered}
\
{source}
\
", ); } else { let _ = writeln!(body, "{source}"); } } else if image { let _ = writeln!( body, "
\"{}\"
", escape(&raw_url), escape(&joined) ); } else if !job.is_link { let _ = writeln!( body, "

This file cannot be previewed.

download raw file
", escape(&raw_url), escape(job.path.last().expect("blob path is never empty")) ); } let title = format!("{} - {} @ {}", joined, ctx.name, ctx.label); let description = format!("{joined} in {} at {}.", ctx.name, ctx.label); write_file( &ctx.out.join(&rel), page(&ctx.instance_name, &title, &description, Some((&ctx.label, ctx.tip_id)), &body), ) } fn reuse_blob( previous: &Path, previous_versions: &BTreeMap, versions: &BTreeMap, path: &str, out: &Path, ) -> bool { let Some(version) = versions.get(path) else { return false; }; if previous_versions.get(path) != Some(version) { return false; } let previous = previous.join("blob").join(path); let out = out.join("blob").join(path); if !previous.is_file() || out.parent().is_none_or(|parent| fs::create_dir_all(parent).is_err()) { return false; } if fs::hard_link(&previous, &out).is_ok() || fs::copy(&previous, &out).is_ok() { return true; } let _ = fs::remove_file(out); false } /// The landing page: repo header plus the HEAD branch's root tree view, with /// entry links pointing into the branch's own pages under `ref/`. pub fn render_site_index( site: &Site, tips: &[RefTip], out: &Path, highlights: Arc, cancel: Option, ) -> Result<()> { let mut body = format!("← back\n{}", site.repo_header()); let head = head_tip(site.repo, tips); match head { None => body.push_str("

no branches yet

\n"), Some(tip) => { let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?; let tree = commit.tree()?; let mut r = RefRenderer::new(site, tips, tip, &commit, out, highlights, cancel); let entries = collect_entries(&tree)?; body.push_str(&commit_panel(site.repo, tip.commit_id)); body.push_str(&r.tree_topbar(&[], &entries)); if tip.static_mode == Some(StaticMode::Highlighted) { body.push_str(&render_languages( &crate::languages::analyze(site.repo, &tree)?, tip.commit_id, )); } body.push_str(&r.overview(&entries)); body.push_str(&r.readme_section(&[], &entries)); } } let description = site .description .clone() .unwrap_or_else(|| format!("{} repository on {}.", site.name, site.instance_name)); write_file( &out.join("index.html"), page(&site.instance_name, &site.name, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body), ) } /// `data-tip` and `data-language` let the client turn each label into a /// link to that language's files at the tip. fn render_languages(stats: &[crate::languages::Stat], tip: gix::ObjectId) -> String { let total = stats.iter().map(|stat| stat.bytes).sum::(); if total == 0 { return String::new(); } let mut html = format!( "
\nlanguages", ); for stat in stats { let percentage = stat.bytes as f64 * 100.0 / total as f64; let _ = write!( html, "", escape(stat.name), percentage, stat.color, ); } html.push_str("\n
    \n"); for stat in stats { let percentage = stat.bytes as f64 * 100.0 / total as f64; let _ = writeln!( html, "
  • {} {:.1}%
  • ", stat.id, stat.color, escape(stat.name), percentage, ); } html.push_str("
\n
\n"); html } /// `/refs`: branch and tag tables with tip summaries and dates. pub fn render_refs_page(site: &Site, tips: &[RefTip], out: &Path) -> Result<()> { let head = head_tip(site.repo, tips); let mut body = format!( "{}
\n", site.repo_header(), site.base_url, escape(site.crumb_name()), ); for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] { // HEAD branch first, then alphabetical; tags newest first let mut dated: Vec<_> = tips .iter() .filter(|t| t.kind == kind) .map(|t| { let commit = site .repo .find_object(t.commit_id) .map_err(anyhow::Error::from) .and_then(|o| Ok(o.try_into_commit()?)); let (summary, time, secs) = match &commit { Ok(c) => ( c.message().map(|m| m.summary().to_string()).unwrap_or_default(), time_of(c), author_time(c).map(|t| t.seconds).unwrap_or(0), ), Err(_) => (String::new(), String::new(), 0), }; (t, summary, time, secs) }) .collect(); if dated.is_empty() { continue; } match kind { RefKind::Branch => dated.sort_by(|a, b| { let is_head = |t: &RefTip| head.is_some_and(|head| head.name == t.name); is_head(b.0).cmp(&is_head(a.0)).then_with(|| a.0.name.cmp(&b.0.name)) }), RefKind::Tag => dated.sort_by_key(|d| std::cmp::Reverse(d.3)), } let _ = write!(body, "

{heading}

\n\n"); for (tip, summary, time, _) in dated { let _ = writeln!( body, "", href = site.ref_href(tip), name = escape(&tip.name), summary = escape(&summary), time = time, ); } body.push_str("
{name}{summary}{time}
\n"); } let title = format!("refs - {}", site.name); let description = format!("Branches and tags for {}.", site.name); write_file( &out.join("refs"), page(&site.instance_name, &title, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body), ) } struct RefRenderer<'a> { site: &'a Site<'a>, tips: &'a [RefTip], out: &'a Path, label: String, /// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`. urlbase: String, tip_id: gix::ObjectId, hl: Highlighter, cancel: Option, } enum EntryKind { Dir, File, Link, Submodule, } type Entry = (String, EntryKind, gix::ObjectId); fn collect_entries(tree: &gix::Tree<'_>) -> Result> { let mut entries = Vec::new(); for entry in tree.iter() { let entry = entry?; let mode = entry.mode(); let kind = if mode.is_tree() { EntryKind::Dir } else if mode.is_link() { EntryKind::Link } else if mode.is_commit() { EntryKind::Submodule } else { EntryKind::File }; entries.push((entry.filename().to_string(), kind, entry.oid().to_owned())); } entries.sort_by(|a, b| { let rank = |k: &EntryKind| !matches!(k, EntryKind::Dir); rank(&a.1).cmp(&rank(&b.1)).then_with(|| a.0.cmp(&b.0)) }); Ok(entries) } /// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone /// directories reads better as one link than as three clicks. fn collapse_lone_dirs(repo: &gix::Repository, name: &str, mut oid: gix::ObjectId) -> String { let mut path = name.to_string(); while let Ok(tree) = repo.find_tree(oid) { let mut entries = tree.iter(); match (entries.next(), entries.next()) { (Some(Ok(only)), None) if only.mode().is_tree() => { let _ = write!(path, "/{}", only.filename()); oid = only.oid().to_owned(); } _ => break, } } path } impl<'a> RefRenderer<'a> { fn new( site: &'a Site<'a>, tips: &'a [RefTip], tip: &RefTip, commit: &gix::Commit, out: &'a Path, highlights: Arc, cancel: Option, ) -> Self { RefRenderer { site, tips, out, label: tip.name.clone(), urlbase: format!("{}ref/{}/", site.base_url, encode_path(&tip.name)), tip_id: commit.id, hl: Highlighter::new(highlights), cancel, } } fn walk( &mut self, tree: &gix::Tree<'_>, path: &mut Vec, blobs: &mut Vec, ) -> Result<()> { bail_if_cancelled(&self.cancel)?; let entries = collect_entries(tree)?; self.tree_page(path, &entries)?; for (name, kind, oid) in entries { path.push(name); match kind { EntryKind::Dir => { let subtree = self.site.repo.find_object(oid)?.try_into_tree()?; self.walk(&subtree, path, blobs)?; } EntryKind::File | EntryKind::Link => { blobs.push(BlobJob { chrome: format!("{}{}", self.switcher(), self.crumbs(path, true)), path: path.clone(), oid, is_link: matches!(kind, EntryKind::Link), }); } EntryKind::Submodule => {} } path.pop(); } Ok(()) } fn tree_page(&mut self, path: &[String], entries: &[Entry]) -> Result<()> { let rel = if path.is_empty() { "index.html".to_string() } else { format!("tree/{}/index.html", path.join("/")) }; let mut body = if path.is_empty() { "← back\n".to_string() } else { String::new() }; body.push_str(&self.site.repo_header()); body.push_str(&commit_panel(self.site.repo, self.tip_id)); body.push_str(&self.tree_topbar(path, entries)); if path.is_empty() { body.push_str(&self.overview(entries)); } else { body.push_str("

files

\n"); body.push_str(&self.listing(path, entries)); } body.push_str(&self.readme_section(path, entries)); let title = if path.is_empty() { format!("{} @ {}", self.site.name, self.label) } else { format!("{}/ - {} @ {}", path.join("/"), self.site.name, self.label) }; let description = if path.is_empty() { format!("Source tree for {} at {}.", self.site.name, self.label) } else { format!( "{} in {} at {}.", path.join("/"), self.site.name, self.label, ) }; write_file( &self.out.join(&rel), page(&self.site.instance_name, &title, &description, Some((&self.label, self.tip_id)), &body), ) } /// Root-page layout: file listing beside a recent-commits log. fn overview(&self, entries: &[Entry]) -> String { format!( "

files

\n{}
\n", self.listing(&[], entries), self.log_section(), ) } /// Recent commits from this ref's tip, jj-style: change id (from the /// `change-id` commit header jj can write), short sha, summary. fn log_section(&self) -> String { const SHOWN: usize = 10; let mut s = String::from("

recent commits

\n
    \n"); let mut shown = Vec::with_capacity(SHOWN); let mut parents = Vec::new(); let mut truncated = false; let Ok(walk) = self.site.repo.rev_walk([self.tip_id]).all() else { return String::new(); }; for (n, info) in walk.flatten().enumerate() { if n == SHOWN { truncated = true; break; } let Ok(commit) = self .site .repo .find_object(info.id) .map_err(anyhow::Error::from) .and_then(|o| Ok(o.try_into_commit()?)) else { continue; }; shown.push(info.id); parents.extend(commit.parent_ids().map(|id| id.detach())); let change_id = commit .decode() .ok() .and_then(|c| c.extra_headers().find("change-id").map(|v| v.to_string())); let cid = change_id .as_deref() .map(|c| { format!( "{} ", info.id, escape(&c[..c.len().min(8)]), ) }) .unwrap_or_default(); let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default(); let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default(); let _ = writeln!( s, "
  1. {cid}{sha} {author}{time}{summary}
  2. ", full = info.id, sha = commit.id().shorten_or_id(), author = escape(&author), time = time_of(&commit), summary = escape(&summary), ); } s.push_str("
\n"); if truncated { let mut frontier = Vec::new(); for parent in parents { if !shown.contains(&parent) && !frontier.contains(&parent) { frontier.push(parent); } } let frontier = frontier .iter() .map(ToString::to_string) .collect::>() .join(" "); let _ = writeln!( s, "

⋯ older commits not shown

", ); } s } fn listing(&self, path: &[String], entries: &[Entry]) -> String { let mut sub = path.join("/"); if !sub.is_empty() { sub.push('/'); } let mut body = "\n".to_string(); for (name, kind, oid) in entries { match kind { EntryKind::Dir => { let name = collapse_lone_dirs(self.site.repo, name, *oid); let _ = writeln!( body, "", base = self.urlbase, href = encode_path(&format!("{sub}{name}")), name = escape(&name), ); } EntryKind::File | EntryKind::Link => { let size = self.site.repo.find_header(*oid).map(|h| h.size()).unwrap_or(0); let _ = writeln!( body, "", base = self.urlbase, href = encode_path(&format!("{sub}{name}")), name = escape(name), sigil = if matches!(kind, EntryKind::Link) { "@" } else { "" }, size = human_size(size), ); } EntryKind::Submodule => { let _ = writeln!( body, "", name = escape(name), ); } } } body.push_str("
{name}/
{name}{sigil}{size}
{name} @ {oid:.8}
\n"); body } fn readme_section(&mut self, path: &[String], entries: &[Entry]) -> String { match find_readme(self.site.repo, entries) { Some((name, data)) => { let text = String::from_utf8_lossy(&data); let readme = if is_markdown(&name) { let urls = RelativeUrls { repo: self.site.repo, base_url: &self.site.base_url, urlbase: &self.urlbase, commit: self.tip_id, directory: path, }; markdown(&mut self.hl, &text, |url, image| urls.resolve(url, image)) } else { format!("
{}
\n", escape(&text)) }; format!("
\n{readme}
\n") } None => String::new(), } } fn tree_topbar(&self, path: &[String], entries: &[Entry]) -> String { let folders = entries.iter().filter(|(_, kind, _)| matches!(kind, EntryKind::Dir)).count(); let files = entries.len() - folders; let mut stats = String::from(""); if folders > 0 { let _ = write!(stats, "{folders} folder{}", if folders == 1 { "" } else { "s" }); } if files > 0 { let _ = write!(stats, "{files} file{}", if files == 1 { "" } else { "s" }); } stats.push_str(""); // the empty actions slot is where the client puts the history link format!( "
{}{}{stats}
\n", self.switcher(), self.crumbs(path, false), ) } /// A no-JS `
` dropdown listing all refs. Baked at build time, so /// pages of refs untouched since a ref was added/deleted list it stale; /// the always-rebuilt index and refs pages stay fresh. fn switcher(&self) -> String { let mut s = format!( "
{}
", escape(&self.label), ); for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] { let mut group = self .tips .iter() .filter(|t| t.kind == kind && t.static_mode.is_some()) .peekable(); if group.peek().is_none() { continue; } let _ = write!(s, "{heading}"); for t in group { let current = t.name == self.label; let _ = write!( s, "{name}", class = if current { " class=\"current\"" } else { "" }, href = self.site.ref_href(t), name = escape(&t.name), ); } } let _ = write!( s, "all refs →
", self.site.base_url, ); s } /// Breadcrumb nav: site name / path components, all but the last linked. fn crumbs(&self, path: &[String], last_is_file: bool) -> String { let mut nav = format!( "\n"); nav } } /// Author time as a `